Skip to content

fix(context): make ContextGraph.add_edge idempotent by deduping on edge_id - #926

Merged
KaifAhmad1 merged 3 commits into
semantica-agi:mainfrom
pravit-amp:fix/922-context-graph-edge-dedupe
Aug 15, 2026
Merged

fix(context): make ContextGraph.add_edge idempotent by deduping on edge_id#926
KaifAhmad1 merged 3 commits into
semantica-agi:mainfrom
pravit-amp:fix/922-context-graph-edge-dedupe

Conversation

@pravit-amp

Copy link
Copy Markdown
Contributor

Fixes #922

ContextGraph._add_internal_edge appended every edge unconditionally, so adding the same edge twice stored two copies sharing one content-derived edge_id. This inflated stats()["edge_count"], pushed density() past 1.0, and made re-ingest double the edge set on every run.

Changes

  • Added an edge_id -> ContextEdge index (_edge_index), mirroring how self.nodes dedupes by node ID.
  • _add_internal_edge now returns False when the edge_id already exists, before touching edges, edge_type_index, or _adjacency, and before firing the mutation callback (no phantom ADD_EDGE audit events).
  • The two state-reset paths (load and clear()) also clear the new index.
  • Four regression tests: repeat add_edge is a no-op, parallel edges with distinct attributes are preserved, re-ingest via build_from_entities_and_relationships stays at one edge, and clear() resets the dedupe index.

Notes

  • This implements the silent no-op option from the issue discussion (option 1). Happy to switch to update-in-place if maintainers prefer.
  • This also resolves the interaction with feat(context): add ContextGraph Markdown round-trip #852: ordinary re-ingested graphs no longer trip that PR's duplicate-edge-ID export guard.

Testing

@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Make ContextGraph.add_edge idempotent by deduping edges on edge_id

🐞 Bug fix 🧪 Tests 🕐 10-20 Minutes

Grey Divider

AI Description

• Add an internal edge_id index to prevent duplicate edge storage.
• Make re-adding an existing edge_id a silent no-op (no index/audit mutations).
• Add regression tests covering idempotency, parallel edges, re-ingest, and clear().
Diagram

graph TD
  A["ContextGraph.add_edge"] --> B["Resolve edge_id"] --> C["_add_internal_edge"] --> D{ "edge_id exists?" }
  D -->|Yes| I["Return False"]
  D -->|No| E[("Edge storage")]
  E --> F["edge_type_index + adjacency"]
  E --> G["mutation_callback"]
  H["load_from_file / clear"] --> E
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Update-in-place on duplicate edge_id (upsert semantics)
  • ➕ Supports refreshing metadata/weight on re-add without creating new edges
  • ➕ Can simplify callers that naturally re-submit edges during re-ingest
  • ➖ Ambiguous behavior for an API named add_edge; re-add would mutate existing state
  • ➖ Harder to define and audit (ADD vs UPDATE) and can mask upstream duplication bugs
2. Deduplicate by (source_id, target_id, edge_type) instead of edge_id
  • ➕ Aligns with a common mental model of 'one relationship per pair/type'
  • ➖ Would collapse legitimate parallel edges that differ by attributes (e.g., confidence, validity window)
  • ➖ Requires defining equality rules across optional fields; higher risk of breaking behavior
3. Make duplicate-edge behavior configurable (no-op vs upsert vs error)
  • ➕ Allows different downstream expectations without changing core logic later
  • ➖ Adds configuration complexity and expands the test matrix and documentation surface

Recommendation: Keep the current silent no-op dedupe keyed by the content-derived edge_id. It directly addresses the reported duplication/density/stat inflation and prevents phantom audit events, while still allowing parallel edges when attributes differ (validated by tests). If update semantics are needed later, add an explicit update_edge/upsert_edge API rather than overloading add_edge.

Files changed (2) +64 / -0

Bug fix (1) +9 / -0
context_graph.pyAdd _edge_index and short-circuit duplicate internal edge inserts +9/-0

Add _edge_index and short-circuit duplicate internal edge inserts

• Introduces an internal _edge_index (edge_id -> ContextEdge) and uses it in _add_internal_edge to return False before mutating edges, edge_type_index, adjacency, or emitting mutation callbacks when an existing edge_id is re-added. Ensures load_from_file() and clear() also clear _edge_index to avoid stale dedupe state after resets.

semantica/context/context_graph.py

Tests (1) +55 / -0
test_context.pyAdd regression tests for edge dedupe and reset behavior +55/-0

Add regression tests for edge dedupe and reset behavior

• Adds tests asserting add_edge is idempotent for identical edges, parallel edges with distinct attributes remain distinct, repeated build_from_entities_and_relationships doesn’t duplicate edges, and clear() resets the dedupe index.

tests/context/test_context.py

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider

Great, no issues found!

Qodo reviewed your code and found no material issues that require review

Grey Divider

Tip of the day
💡 Did you know, you can enable the Remediation agent and Qodo fixes findings in a dedicated fix PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

KaifAhmad1 and others added 2 commits August 15, 2026 15:52
Adds an Unreleased/Fixed entry for semantica-agi#922/semantica-agi#926 so the ContextGraph
edge-dedupe bug and its fix are recorded per Keep a Changelog format.

@KaifAhmad1 KaifAhmad1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified this end-to-end, not just read the diff — pulled the branch into a worktree and ran it:

  • _edge_index dedupe check sits inside the existing self._lock (RLock, so no deadlock with the outer lock in add_edge), runs before node auto-creation and before the mutation callback fires — a repeat add_edge is a true no-op, no phantom ADD_EDGE audit event.
  • Grepped every write path to self.edges_add_internal_edge is the only append site, and all callers (bulk add_edges, builders, load_from_file, merge, decision-graph helpers) route through it, so the index can't go stale. Both load_from_file() and clear() also clear _edge_index.
  • Ran all three repro scripts from #922 directly against this branch: edges=1/density=0.5 for repeated add_edge (was 3/1.5), parallel edges with distinct attributes still produce 3 distinct IDs, and triple re-ingest via build_from_entities_and_relationships stays at edges=1 across all 3 runs (was 1→2→3).
  • Full tests/context/test_context.py: 31/31 passed, including the 4 new regression tests.
  • Checked the interaction with add_edge's pre-existing SKOS hierarchy cycle check — re-adding an identical skos:broader/narrower edge doesn't trip a false cycle error.

Clean fix, mirrors the existing node-dedupe pattern, good test coverage. Also resolves the interaction with #852's Markdown export guard as described.

Thanks for the thorough writeup and reproducible repro cases in the issue, @pravit-amp — made this easy to verify. Approving.

@KaifAhmad1
KaifAhmad1 merged commit 84ce3c5 into semantica-agi:main Aug 15, 2026
10 checks passed
KaifAhmad1 added a commit to pravit-amp/semantica that referenced this pull request Aug 15, 2026
edge_id is content-derived and not yet guaranteed unique (semantica-agi#922, fix
pending in semantica-agi#926): two identical add_edge() calls produce two edge
objects sharing one id. retract_edge()/purge_edge() resolved "the
edge" via the first matching object only, so a duplicate was silently
left untouched (still live, still active) while the call returned
True and recorded a tombstone/retraction claiming it was fully
handled. Repeat purge_edge() calls also silently overwrote the
tombstone's reason/purged_at on each partial attempt instead of
no-op'ing once nothing remained to purge.

retract_node()'s cascade had the same root cause from the other
direction: it checked the live _retractions dict mid-loop, so the
first duplicate's just-written record made the second look already
handled and it was skipped outright, left permanently active.

retract_edge()/purge_edge() now act on every edge matching the id
under a single record; the cascade's dedup check is snapshotted
before the loop starts so within-call duplicates are still closed
rather than skipped.

Adds TestDuplicateEdgeId (5 tests) reproducing all three paths.
KaifAhmad1 added a commit that referenced this pull request Aug 15, 2026
* feat(context): add retraction and purge to ContextGraph

ContextGraph had 56 public methods and none that removed anything: the only
option was clear(), which discards the whole graph. Removing one entity meant
exporting to a dict, filtering by hand and rebuilding, losing provenance.

Add two operations with deliberately different contracts.

retract_node/retract_edge close the entity's validity window. The entity stops
being active going forward, but state_at() before the retraction still returns
it, so decisions recorded against it remain explainable. This reuses the
valid_from/valid_until machinery already present rather than adding a new
subsystem.

purge_node/purge_edge remove the entity outright, from history as well as from
the active view, leaving a tombstone that records that a purge happened and why
but never the purged content. Scope is this graph only; copies in AgentMemory
or a bound vector store are not reached, so it is one step of an erasure
workflow rather than the whole of it.

Both record themselves through the existing mutation_callback path.
MutationRecord already documented REMOVE_NODE/REMOVE_EDGE in its operation
vocabulary, so retraction emits UPDATE_NODE and purge emits REMOVE_NODE with no
changes required to change_management.

Incident-edge lookup scans self.edges rather than _adjacency, which is keyed by
source only and would otherwise leave inbound edges pointing at a removed node.
Purge updates edges, edge_type_index and _adjacency together so the indexes
cannot drift, and clear() now resets the retraction and tombstone records.

* fix(context): address review findings on retraction and purge

* fix(context): close every duplicate when retracting/purging by edge_id

edge_id is content-derived and not yet guaranteed unique (#922, fix
pending in #926): two identical add_edge() calls produce two edge
objects sharing one id. retract_edge()/purge_edge() resolved "the
edge" via the first matching object only, so a duplicate was silently
left untouched (still live, still active) while the call returned
True and recorded a tombstone/retraction claiming it was fully
handled. Repeat purge_edge() calls also silently overwrote the
tombstone's reason/purged_at on each partial attempt instead of
no-op'ing once nothing remained to purge.

retract_node()'s cascade had the same root cause from the other
direction: it checked the live _retractions dict mid-loop, so the
first duplicate's just-written record made the second look already
handled and it was skipped outright, left permanently active.

retract_edge()/purge_edge() now act on every edge matching the id
under a single record; the cascade's dedup check is snapshotted
before the loop starts so within-call duplicates are still closed
rather than skipped.

Adds TestDuplicateEdgeId (5 tests) reproducing all three paths.

* docs(changelog): document retraction/purge feature

Adds an Unreleased/Added entry for #955/#957 covering retract_node,
retract_edge, purge_node, purge_edge and the get/list accessors, plus
the duplicate-edge_id fix caught and applied during review.

---------

Co-authored-by: Pravit Ampapathini <pravitampapathini@Pravits-MacBook-Air-3.local>
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(context): ContextGraph.add_edge has no dedupe — identical edges are stored repeatedly under one shared edge ID, and re-ingest doubles the edge set

2 participants